GPT摘要 
这篇文章详细介绍了操作系统启动过程中 main.c 中的 main 方法的执行流程。主要内容如下:  1. 内存边界设置 :根据内存大小设置 memory_end 和 buffer_memory_end,标记内存使用的范围。 2. 内存管理初始化 :mem_init 初始化内存分页管理,使用 mem_map 表记录内存页使用状态,方便后续的内存申请与释放。 3. 中断初始化 :trap_init 设置了各类中断处理函数,包括时钟中断(timer_interrupt)和键盘中断(keyboard_interrupt),并通过 sti() 开启中断响应。 4. 进程调度初始化 :sched_init 设置任务状态段(TSS)和局部描述符表(LDT),初始化 task_struct 数组,并为时钟中断和系统调用(0x80 中断)配置处理函数,为多进程调度奠定基础。 5. 字符设备与块设备初始化 :tty_init 负责控制台初始化(显存映射、光标设置),blk_dev_init 初始化块设备请求队列,hd_init 配置硬盘读写相关中断与请求处理函数。 6. 缓冲管理 :buffer_init 建立缓冲区与哈希表,作为用户进程与硬盘间的桥梁,优化数据读取效率。 7. 切换到用户态 :move_to_user_mode 创建第一个进程(init),最终进入 shell 等待用户输入。  文章通过源码逐层解析,展示了操作系统如何通过中断驱动、内存管理和进程调度等核心机制逐步启动,并最终为用户提供交互环境。
 
引入 在上一回中,跳转到操作系统的骨架代码main.c中的main方法了, 数一数看,总共也就 20 几行代码。但这的确是操作系统启动流程的全部秘密了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 void  main (void )  {1 <<20 ) + (EXT_MEM_K<<10 );0xfffff000 ;if  (memory_end > 16 *1024 *1024 )16 *1024 *1024 ;if  (memory_end > 12 *1024 *1024 ) 4 *1024 *1024 ;else  if  (memory_end > 6 *1024 *1024 )2 *1024 *1024 ;else 1 *1024 *1024 ;if  (!fork()) {for (;;) pause();
主内存初始化mem_init 首先设置了内存的边界,包含memory和buffer,边界的设置是根据不同的内存大小设置的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #define  LOW_MEM 0x100000 #define  PAGING_MEMORY (15*1024*1024) #define  PAGING_PAGES (PAGING_MEMORY>>12) #define  MAP_NR(addr) (((addr)-LOW_MEM)>>12) #define  USED 100 static  long  HIGH_MEMORY = 0 ;static  unsigned  char  mem_map[PAGING_PAGES] = { 0 , };void  mem_init (long  start_mem, long  end_mem) int  i;for  (i=0  ; i<PAGING_PAGES ; i++)12 ;while  (end_mem-->0 )0 ;
如何管理内存? 
举个例子 比如我们在 fork 子进程的时候,会调用 copy_process  函数来复制进程的结构信息,其中有一个步骤就是要申请一页内存 ,用于存放进程结构信息 task_struct。该内存的申请就是选择 mem_map 中首个空闲页面,并标记为已使用。
1 2 3 4 5 6 int  copy_process (...)  {struct  task_struct  *p ;struct  task_struct *) get_free_page();
中断初始化trap_init 当你的计算机刚刚启动时,你按下键盘是不生效的,但是过了一段时间后,再按下键盘就有效果了。如何首先的呢,多久会生效呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 void  trap_init (void )  {int  i;0 ,÷_error);1 ,&debug);2 ,&nmi);3 ,&int3);   4 ,&overflow);5 ,&bounds);6 ,&invalid_op);7 ,&device_not_available);8 ,&double_fault);9 ,&coprocessor_segment_overrun);10 ,&invalid_TSS);11 ,&segment_not_present);12 ,&stack_segment);13 ,&general_protection);14 ,&page_fault);15 ,&reserved);16 ,&coprocessor_error);for  (i=17 ;i<48 ;i++)45 ,&irq13);39 ,¶llel_interrupt);
TIPS:set_trap_gate和set_system_gate什么关系? 
这个 trap 与 system 的区别仅仅在于,设置的中断描述符的特权级不同,前者是 0(内核态),后者是 3(用户态),这块展开将会是非常严谨的、绕口的、复杂的特权级相关的知识,不明白的话先不用管,就理解为都是设置一个中断号和中断处理程序的对应关系就好了。
1 2 3 4 5 #define  set_trap_gate(n,addr) \     _set_gate(&idt[n],15,0,addr) #define  set_system_gate(n,addr) \     _set_gate(&idt[n],15,3,addr) 
 
_set_gate就是设置一些列的硬中断处理函数,执行过后,IDT指向的结构变成了:
什么时候开始中断呢?sti();  也就是这时候开始,按下键盘可以有反应了。
块设备请求项初始化 blk_dev_init 用于让我们能够从硬盘读取数据到磁盘,读取块设备与内存缓冲区之间的桥梁
1 2 3 4 5 6 7 void  blk_dev_init (void )  {int  i;for  (i=0 ; i<32 ; i++) {-1 ;NULL ;
其中request请求,代表着一次读盘请求
1 2 3 4 5 6 7 8 9 10 11 struct  request  {int  dev;        int  cmd;        int  errors;		unsigned  long  sector;	unsigned  long  nr_sectors; char  * buffer;			struct  task_struct  * waiting ;struct  buffer_head  * bh ;struct  request  * next ;
这个 request 结构可以完整描述一个读盘操作。然后那个 request 数组就是把它们都放在一起,并且它们又通过 next 指针串成链表。
如何添加到request链 sys_read 核心代码
1 2 3 4 5 6 7 8 9 int  sys_read (unsigned  int  fd,char  * buf,int  count)  {struct  file  * file  =struct  m_inode  * inode  =return  file_read(inode,file,buf,count);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 int  file_read (struct  m_inode * inode, struct  file * filp, char  * buf, int  count)  {int  left,chars,nr;struct  buffer_head  * bh ;while  (left) {if  (nr = bmap(inode,(filp->f_pos)/BLOCK_SIZE)) {if  (!(bh=bread(inode->i_dev,nr)))break ;else NULL ;if  (bh) {char  * p = nr + bh->b_data;while  (chars-->0 )else  {while  (chars-->0 )0 ,buf++);return  (count-left)?(count-left):-ERROR;
接着就是不断的读取数据到我们的buf中,从代码中可以看到是从buffer_head * bh中读的,这是什么?
1 2 3 4 5 6 7 8 9 10 11 struct  buffer_head * bread (int  dev,int  block)  { struct  buffer_head  * bh  =if  (bh->b_uptodate)return  bh;if  (bh->b_uptodate)return  bh;return  NULL ;
其中 getblk 先申请了一个内存中的缓冲块,然后 ll_rw_block 负责把数据读入这个缓冲块,进去继续看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 void  ll_rw_block (int  rw, struct  buffer_head * bh)  {static  void  make_request (int  major,int  rw, struct  buffer_head * bh)  {if  (rw == READ)else 2 )/3 );while  (--req >= request)if  (req->dev<0 )break ;0 ;1 ;  2 ;NULL ;NULL ;
ll_rw_block会往刚刚的设备的请求项链表 request[32] 中添加一个请求项,作为访问块设备和内存缓冲区之间的桥梁 
请求队列中的请求由I/O调度器进行管理。I/O调度器负责确定请求的执行顺序,优化整体的磁盘性能和响应时间。Linux提供了多种I/O调度器,如CFQ(完全公平队列)、Deadline、NOOP等,不同的调度器适用于不同类型的工作负载和硬件配置。 
 
控制台初始化 tty_init 1 2 3 4 5 void  tty_init (void ) 
1 2 3 4 5 6 7 8 9 10 11 12 13 void  con_init (void )  {if  (ORIG_VIDEO_MODE == 7 ) {if  ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...}else  {...}else  {if  ((ORIG_VIDEO_EGA_BX & 0xff ) != 0x10 ) {...}else  {...}
非常多的 if else。为了应对不同的显示模式,来分配不同的变量值,那如果我们仅仅找出一个显示模式,这些分支就可以只看一个了。
如何显示一个字符 啥是显示模式呢?那我们得简单说说显示,一个字符是如何显示在屏幕上的呢 ?换句话说,如果你可以随意操作内存和 CPU 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。mov [0xB8000],'h' 
代码 假设显示模式是我们现在的这种文本模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 #define  ORIG_X          (*(unsigned char *)0x90000) #define  ORIG_Y          (*(unsigned char *)0x90001) void  con_init (void )  {register  unsigned  char  a;unsigned  short  *)0x90006 ) & 0xff00 ) >> 8 ); 2 ;25 ;unsigned  short  *)0x90004 );0x0720 ;0xb8000 ;0x3d4 ;0x3d5 ;0xba000 ;0 ;0x21 ,&keyboard_interrupt); 0x21 )&0xfd ,0x21 );0x61 );0x80 ,0x61 );0x61 );
1 2 3 4 5 6 static  inline  void  gotoxy (unsigned  int  new_x,unsigned  int  new_y)  {1 );
x  表示光标在哪一列,y  表示光标在哪一行,pos  表示根据列号和行号计算出来的内存指针,也就是往这个 pos 指向的地址处写数据,就相当于往控制台的 x 列 y 行处写入字符了
 
键盘中断 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 _keyboard_interrupt:void  do_tty_interrupt (int  tty)  {void  copy_to_cooked (struct  tty_struct * tty)  {void  con_write (struct  tty_struct * tty)  {"movb _attr,%%ah\n\t" "movw %%ax,%1\n\t" "a"  (c),"m"  (*(short  *)pos)"ax" );2 ;
至此我们可以实现显示功能了,本质就是往内存中pos位置写值,那回车 、换行 、删除 、滚屏 、清屏 等操作,其实底层都操作x y pos,然后修改内存就行,并对外暴露小功能函数
在此之后,内核代码就可以用它来方便地在控制台输出字符啦!这在之后内核想要在启动过程中告诉用户一些信息,以及后面内核完全建立起来之后,由用户用 shell 进行操作时手动输入命令,都是可以用到这里的代码的!
printf 这样的库函数,在屏幕上输出信息,同时支持换行和滚屏等友好设计,这些都是 tty_init 初始化,以及其对外封装的小功能函数,来实现的。
时间初始化 time_init 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #define  CMOS_READ(addr) ({ \     outb_p(0x80|addr,0x70); \     inb_p(0x71); \ }) #define  BCD_TO_BIN(val) ((val)=((val)&15) + ((val)>>4)*10) static  void  time_init (void )  {struct  tm  time ;do  {0 );2 );4 );7 );8 );9 );while  (time.tm_sec != CMOS_READ(0 ));
1 2 3 4 5 #define  CMOS_READ(addr) ({ \     outb_p(0x80|addr,0x70); \     inb_p(0x71); \ }) 
这是 CPU 与外设交互的一个基本玩法,CPU 与外设打交道基本是通过端口,往某些端口写值来表示要这个外设干嘛,然后从另一些端口读值来接受外设的反馈。
以磁盘为例:读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节的读数据。这就完成了一次硬盘读操作。
在 0x1F2 写入要读取的扇区数 
在 0x1F3 ~ 0x1F6 这四个端口写入计算好的起始 LBA 地址 
在 0x1F7 处写入读命令的指令号 
不断检测 0x1F7 (此时已成为状态寄存器的含义)的忙位 
如果第四步骤为不忙,则开始不断从 0x1F0 处读取数据到内存指定位置,直到读完 
 
当然,读取硬盘的这个无脑循环,可以 CPU  直接读取并做写入内存的操作,这样就会占用 CPU 的计算资源。
也可以交给 DMA  设备去读,解放 CPU,但和硬盘的交互,通通都是按照硬件手册上的端口说明,来操作的,实际上也是做了一层封装。
而在时间读取中,就是和CMOS (主板上的一个可读写的 RAM)这个外设打交道,让他告诉我们时间
CMOS:计算机主板上的一小块芯片,这块芯片使用CMOS技术来存储BIOS(基本输入输出系统)设置等基础系统信息。这些信息包括系统时间、硬件配置设置等,这部分内存被称为CMOS  RAM或非易失性BIOS内存。由于CMOS技术的低功耗特点,即使在计算机断电后,CMOS内存也能通过一个小电池供电维持数据存储。这使得计算机在下一次开机时可以记住之前的配置设置。
 
这方法可了不起,因为它就是多进程的基石!
终于来到了兴奋的时刻,是不是很激动?不过先别激动,这里只是进程调度的初始化,也就是为进程调度所需要用到的数据结构做个准备,真正的进程调度还需要调度算法、时钟中断等机制的配合。
当然,对于理解操作系统,流程和数据结构最为重要了,而这一段作为整个流程的起点,以及建立数据结构的地方,就显得格外重要了。
1 2 3 4 5 6 void  sched_init (void )  {4 , &(init_task.task.tss));5 , &(init_task.task.ldt));0 ,如果由其他线程还要继续加
TSS TSS 在计算机中代表任务状态段 (Task State  Segment)。它是一种数据结构,用于存储处理器在任务切换时必须保存的特定任务的状态信息(寄存器的值)。每个任务都有一个对应的  TSS。当操作系统执行任务切换时,它会使用 TSS 来保存当前任务的状态,并加载新任务的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 struct  tss_struct {long  back_link;long  esp0;long  ss0;long  esp1;long  ss1;long  esp2;long  ss2;long  cr3;long  eip;long  eflags;long  eax, ecx, edx, ebx;long  esp;long  ebp;long  esi;long  edi;long  es;long  cs;long  ss;long  ds;long  fs;long  gs;long  ldt;long  trace_bitmap;struct  i387_struct  i387 ;
LDT LDT 叫局部描述符表 (Local Descriptor Table),是与 GDT 全局描述符表相对应的,内核态的代码用 GDT 里的数据段和代码段,而用户进程的代码用每个用户进程自己的 LDT 里得数据段和代码段。
task[] 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct  desc_struct  {unsigned  long  a,b; struct  task_struct  * task [64] =void  sched_init (void )  {int  i;struct  desc_struct  * p ;6 ;for (i=1 ;i<64 ;i++) {NULL ;0 ;0 ;
这个 task_struct 结构就是代表每一个进程的信息 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 struct  task_struct  {long  state; long  counter;long  priority;long  signal;struct  sigaction  sigaction [32];long  blocked; int  exit_code;unsigned  long  start_code,end_code,end_data,brk,start_stack;long  pid,father,pgrp,session,leader;unsigned  short  uid,euid,suid;unsigned  short  gid,egid,sgid;long  alarm;long  utime,stime,cutime,cstime,start_time;unsigned  short  used_math;int  tty;  unsigned  short  umask;struct  m_inode  * pwd ;struct  m_inode  * root ;struct  m_inode  * executable ;unsigned  long  close_on_exec;struct  file  * filp [NR_OPEN ];struct  desc_struct  ldt [3];struct  tss_struct  tss ;
接下来告诉通过寄存器告诉系统当前任务的LDT,TSS的位置(内存里每个线程都有LDT,TSS,当前线程的是哪一个),初始指向了第0个
1 2 3 4 5 6 void  sched_init (void )  {0 );0 );
最后 1 2 3 4 5 6 7 8 9 10 11 12 void  sched_init (void )  {0x36 ,0x43 );      0xff  , 0x40 );    8  , 0x40 );    0x20 ,&timer_interrupt);  0x21 )&~0x01 ,0x21 );0x80 ,&system_call); 
四行端口读写代码,两行设置中断代码。
端口读写我们已经很熟悉了,就是 CPU 与外设交互的一种方式,之前讲硬盘读写以及 CMOS 读写时,已经接触过了。
而这次交互的外设是一个可编程定时器 的芯片,这四行代码就开启了这个定时器,之后这个定时器变会持续的、以一定频率的向 CPU 发出中断信号 。
第一个就是时钟中断 ,中断号为 0x20 ,中断处理程序为 timer_interrupt 。那么每次定时器向 CPU 发出中断后,便会执行这个函数。
更新系统时间或运行时间计数器。 
检查和执行定时任务或超时事件(网络通信和用户交互)。 
对当前运行的进程或线程的运行时间进行计量,以便进行任务调度。 
 
是操作系统主导进程调度的一个关键!
 
第二个设置的中断叫系统调用 system_call ,中断号是 0x80 ,这个中断又是个非常非常非常非常非常非常非常重要的中断,所有用户态程序想要调用内核提供的方法,都需要基于这个系统调用来进行。
Java 程序员写一个 read,底层会执行汇编指令 int 0x80 ,这就会触发系统调用这个中断,最终调用到 Linux 里的 sys_read 方法。
 
 
中断号 
中断处理函数 
 
 
0 ~ 0x10 (trap_init) 
trap_init 里设置的一堆 
 
0x20 
timer_interrupt 
 
0x21 (tty_init) 
keyboard_interrupt 
 
0x80 
system_call 
 
找到些感觉没,有没有越来越发现,操作系统有点靠中断驱动的意思,各个模块不断初始化各种中断处理函数,并且开启指定的外设开关,让操作系统自己慢慢“活”了起来,逐渐通过中断忙碌于各种事情中,无法自拔。
 
小结 
我们往GDT中写入了TSS和LDT,作为每一个线程的任务状态和局部描述符表 初始: init_task.task.tss init_task.task.ldt 
初始化了task_struct [] ,并且第一个位置init_task.task 
设置了时钟中断0x20和系统调用0x80 
 
在讲通过文件系统来读取硬盘文件时,都需要使用和弃用这个缓冲区里的内容,缓冲区即是用户进程的内存和硬盘之间的桥梁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 extern  int  end; struct  buffer_head  * start_buffer  =struct  buffer_head *) &end;struct  buffer_head  {unsigned  long  b_state;char  *b_data;struct  block_device  *b_dev ;unsigned  long  b_blocknr;       struct  buffer_head  *b_this_page ;struct  buffer_head  *b_this_free ;struct  buffer_head  *b_next ;atomic_t  b_count;void  buffer_init (long  buffer_end)  {struct  buffer_head  * h  =void  * b = (void  *) buffer_end;while  ( (b -= 1024 ) >= ((void  *) (h+1 )) ) {0 ;0 ;0 ;0 ;0 ;NULL ;NULL ;NULL ;char  *) b;-1 ;1 ;for  (int  i=0 ;i<307 ;i++)NULL ;
缓冲头记录下头信息,并通过pre next串联起来 
b_data指向真正的数据区 
 
读取块设备的数据(硬盘中的数据),需要先读到缓冲区中,如果缓冲区已有了,就不用从块设备读取了,直接取走 
怎么知道缓冲区已经有了要读取的块设备中的数据呢? 遍历效率太低,直接hash 
key是什么? (设备号^逻辑块号) Mod 307   冲突用链表解决 
如何实现淘汰呢?哈希表 + 双向链表 实现LRU  
 
hd_init  是硬盘初始化 ,我们不得不看floppy_init  是软盘初始化 ,现在软盘几乎都被淘汰了,计算机中也没有软盘驱动器了,所以这个我们完全可以不看 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 struct  blk_dev_struct  blk_dev [NR_BLK_DEV ] =NULL , NULL  },     NULL , NULL  },     NULL , NULL  },     NULL , NULL  },     NULL , NULL  },     NULL , NULL  },     NULL , NULL  }      void  hd_init (void )  {3 ].request_fn = do_hd_request; 0x2E ,&hd_interrupt); 0x21 )&0xfb ,0x21 );0xA1 )&0xbf ,0xA1 ); 
往某些 IO 端口上读写一些数据,表示开启它; 
然后再向中断向量表中添加一个中断,使得 CPU 能够响应这个硬件设备的动作; 
最后再初始化一些数据结构来管理。不过像是内存管理可能结构复杂些,外设的管理,相对就简单很多了。 
 
操作系统就是一个靠中断驱动的死循环而已 ,如果不发生任何中断,操作系统会一直在一个死循环里等待。换句话说,让操作系统工作的唯一方式,就是触发中断。
 
硬盘端口 端口对应硬盘控制器上的寄存器
端口 
读 
写 
 
 
0x1F0 
数据寄存器 
数据寄存器 
 
0x1F1 
错误寄存器 
特征寄存器 
 
0x1F2 
扇区计数寄存器 
扇区计数寄存器 
 
0x1F3 
扇区号寄存器或 LBA 块地址 0~7 
扇区号或 LBA 块地址 0~7 
 
0x1F4 
磁道数低 8 位或 LBA 块地址 8~15 
磁道数低 8 位或 LBA 块地址 8~15 
 
0x1F5 
磁道数高 8 位或 LBA 块地址 16~23 
磁道数高 8 位或 LBA 块地址 16~23 
 
0x1F6 
驱动器/磁头或 LBA 块地址 24~27 
驱动器/磁头或 LBA 块地址 24~27 
 
0x1F7 
命令寄存器或状态寄存器 
命令寄存器 
 
那读硬盘就是,往除了第一个以外的后面几个端口写数据,告诉要读硬盘的哪个扇区,读多少。然后再从 0x1F0 端口一个字节一个字节的读数据。这就完成了一次硬盘读操作。
如果觉得不够具体,那来个具体的版本。